package org.seefin.formats.iso8583;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.ParseException;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeSet;

import javax.annotation.PostConstruct;

import org.apache.commons.beanutils.PropertyUtils;
import org.seefin.formats.iso8583.formatters.TypeFormatter;
import org.seefin.formats.iso8583.formatters.TypeFormatters;
import org.seefin.formats.iso8583.io.BCDMessageWriter;
import org.seefin.formats.iso8583.io.CharMessageWriter;
import org.seefin.formats.iso8583.io.MessageWriter;
import org.seefin.formats.iso8583.types.BitmapType;
import org.seefin.formats.iso8583.types.CharEncoder;
import org.seefin.formats.iso8583.types.ContentType;
import org.seefin.formats.iso8583.types.MTI;
import org.springframework.beans.factory.annotation.Autowired;


/**
 * ISO8583 Message factory, configured with a number of message templates (a schema),
 * capable to creating and parsing ISO8583 messages
 * <p/>
 * Usually configured via an XML specification (<code>&lt;iso:schema&gt;</code>), it may also be
 * created by setting the following fields and calling <code>initialize()</code>:
 * <dl>
 * <dt>header</dt><dd>Text header to prepend to messages, e.g., ISO015000077</dd>
 * <dt>messages</dt><dd>A map of MTI:MessageTemplates, defining the messages
 * 		understood by this factory instance</dd>
 * <dt>contentType</dt><dd>Enumeration specifying the content type of messages
 * 	created or parsed (one of BCD, ASCII, EBCDIC)</dd>
 * <dt>bitmapType</dt><dd>Type of bitmap to be used, one of BINARY, HEX</dd>
 * </dl>
 * @author phillipsr
 *
 */
public class MessageFactory
{
	private Map<MTI,MessageTemplate> messages = new HashMap<MTI,MessageTemplate>();
	private BitmapType bitmapType = BitmapType.HEX;
	private ContentType contentType = ContentType.TEXT;
	private CharEncoder charset = CharEncoder.ASCII;
	private String header = "";
	private String description;
	private String id;
	private boolean strict = Boolean.TRUE;
	private TypeFormatters formatters;
	private MessageParser parser;
	@Autowired(required=false)
	private AutoGeneratorFactory autoGenerator;
	
	@PostConstruct
	public void
	initialize()
	{
		if ( messages == null)
		{
			throw new IllegalStateException ("Factory has no message definitions: cannot be initialized");
		}
		if ( formatters == null)
		{
			formatters = new TypeFormatters(charset);
		}
		if ( parser == null)
		{
			parser = new MessageParser ( header, messages, contentType, charset, bitmapType);
		}
	}

	public boolean isStrict () { return strict; }
	public void setStrict ( boolean strict)
		{ this.strict = strict; }
	
	/** Answer with the default bitmap type used in this factory */
	public BitmapType getBitmapType() { return bitmapType; }
	
	/**
	 * Set the bitmap type, one of BINARY or HEX
	 * @param bitmapType
	 * @throws IllegalArgumentException if bitmapType is null
	 */
	public void
	setBitmapType ( BitmapType bitmapType)
	{ 
		if ( bitmapType == null)
		{
			throw new IllegalArgumentException ( "bitmapType may not be null");
		}
		this.bitmapType = bitmapType; 
	}
	
	/** Answer with the default message context type used in this factory */
	public ContentType getContentType () { return contentType; }
	
	/**
	 * Set the message (numeric) content type, one of ASCII, EBCDIC or BCD
	 * @param contentType
	 * @throws IllegalArgumentException if the content type is null
	 */
	public void
	setContentType ( ContentType contentType)
	{
		if ( contentType == null)
		{
			throw new IllegalArgumentException ( 
					"contentType cannot not be null, must be one of: " + Arrays.toString ( ContentType.values ()));
		}
		this.contentType = contentType; 
	}
	
	public CharEncoder getCharset() { return charset; }
	public void
	setCharset ( CharEncoder charset)
	{
		if ( charset == null )
		{
			throw new IllegalArgumentException ( "charset cannot be null");
		}
		this.charset = charset;
	}
	
	/** Answer with the header field value used (can be null) */
	public String getHeader () { return header; }
	/**
	 * Set the value of the header field, prepended to messages generated by, 
	 * and expected at the start of messages parsed by this factory
	 * @param header field value (can be null or empty)
	 */
	public void setHeader ( String header)
		{ this.header = header; }
	
	/** Answer with the text description of this factory: not used in message creatio */
	public String getDescription () { return description; }
	/**
	 * Set the description of this factory; this is for documentary purposes, and is
	 * usually set from with the iso:schema XML element
	 * @param description
	 */
	public void setDescription ( String description)
		{ this.description = description; }
	
	/** Answer with the Spring bean ID */
	public String getId () { return id; }
	/** Set the Spring bean ID for this factory; typically only used
	 * for referencing the factory bean for Spring usage
	 * @param id
	 */
	public void setId ( String id)
		{ this.id = id; }
	
	/**
	 * Set Auto-generator to use for the automatic generation of field values;
	 * this is an optional dependency, and will only be used of fields are 
	 * defined in the schema with an 'autogen' property.
	 * </p>
	 * The usual way to set this field is via Spring IoC
	 */
	public void setAutoGeneratorFactory ( AutoGeneratorFactory autoGenerator)
		{ this.autoGenerator = autoGenerator; }
	
	/**
	 * Answer with the ISO8583 messages defined in this factory's schema
	 * @return
	 */
	public Collection<MessageTemplate>
	getMessages ()
	{
		return messages.values ();
	}
	
	/**
	 * Add a message to this factory's schema
	 * @param message
	 */
	public void
	addMessage ( MessageTemplate message)
	{
		message.setSchema ( this);
		this.messages.put ( message.getMessageTypeIndicator (), message);
	}
	
	/**
	 * Answer with a string representation of this message factory
	 */
	@Override
	public String
	toString()
	{
		return "MessageFactory id=" + getId()
				+ " description='" + getDescription() + "'"
				+ " header=" + getHeader() 
				+ " contentType=" + getContentType()
				+ " charset=" + getCharset()
				+ " bitmapType=" + getBitmapType()
				+ (messages != null ? (" messages# " + messages.size ()) : "");
	}
	
	/**
	 * Create an ISO8583 message of the type requested, setting the field values
	 * from the supplied parameter map, keyed by field number, matching 
	 * <code>&lt;iso:message&gt;</code> configuration for this message type
	 * 
	 * @param mti type of message to create
	 * @param params field value to include in message, indexed by field number
	 * @return a new message instance for the requested type, 
	 * 			with fields set from the parameters supplied
	 * @throws IllegalArgumentException - if the supplied MTI is null
	 */
	public Message
	createByNumbers ( MTI type, Map<Integer, Object> params)
	{
		Message result = new Message ( type);
		result.setFields ( params);
		result.setHeader ( header);
		result.setTemplate ( messages.get ( type));
		return result;
	}
	
	/**
	 * Write a message to the supplied <code>output</code> stream
	 * @see #writeFromNumberMap(MTI, Map, OutputStream)
	 * @param message
	 * @param output
	 * @throws IOException 
	 */
	public void
	writeToStream ( Message message, OutputStream output)
		throws IOException
	{
		writeFromNumberMap ( message.getMTI (), message.getFields (), output);
	}
	
	/**
	 * Create a message for the type and parameters specified and write it to
	 * the <code>output</code> stream
	 * @param type of the message to be written
	 * @param params map of field # to field value (maybe updated if autogen or default required)
	 * @param output stream to write formatted ISO8583 message onto
	 * @throws IOException if writing to the output stream fails for any reason
	 * @throws IllegalArgumentException if the type supplied is not defined in this factory's schema,
	 * 			the output stream is null or null/empty message parameters have been supplied
	 */
	public void
	writeFromNumberMap ( MTI type, Map<Integer, Object> params, OutputStream output)
		throws IOException
	{
		if ( messages.containsKey ( type) == false)
		{
			throw new IllegalArgumentException ( "Message not defined for MTI=" + type);
		}
		if ( output == null)
		{
			throw new IllegalArgumentException ( "Output stream cannot be null");
		}
		if ( params == null || params.isEmpty ())
		{
			throw new IllegalArgumentException ( "Message parameters are required");
		}
		
		MessageTemplate template = messages.get ( type);
		MessageWriter writer = getOutputWriter ( contentType, charset);
		DataOutputStream dos = getDataOutputStream ( output);

		writer.appendHeader ( header, dos);
		writer.appendMTI ( type, dos);
		writer.appendBitmap ( template.getBitmap(), bitmapType, dos);

		// Iterate over the fields in order of field number,
		// appending the field's data to the output stream
		TreeSet<Integer> index = new TreeSet<Integer>( template.getFields ().keySet ());
		for ( Integer key : index)
		{
			FieldTemplate field = template.getFields ().get ( key);
			Object data = params.get ( field.getNumber());
			if ( data == null && field.isOptional () == false)
			{
				// first, try to autogen, and then fall back to default (if any)
				String autogen = field.getAutogen ();
				if ( autogen != null && autogen.isEmpty () == false)
				{
					if ( autoGenerator == null)
					{
						throw new IllegalStateException ( 
								"Message requires AutoGen field, but the (optional) AutoGenerator has not been set in the MessageFactory");
					}
					data = autoGenerator.generate ( autogen, field);
				}
				if ( data == null)
				{
					data = field.getDefaultValue ();
				}
				if ( data == null)
				{
					throw new MessageException ( "Value is <null> for field: " + field);
				}
				// write the autogen'd/default value back into the parameter set for consistency
				params.put ( field.getNumber(), data);
			}
			if ( data != null)
			{
				writer.appendField ( field, data, dos);
			}
		}
		dos.flush ();
	}

	/**
	 * Wrap the supplied output stream in a DataOutputStream, if required
	 * @param output
	 * @return
	 */
	private DataOutputStream
	getDataOutputStream ( OutputStream output)
	{
		if ( output instanceof DataOutputStream)
		{
			return (DataOutputStream)output;
		}
		return new DataOutputStream ( output);
	}
	
	/**
	 * Return the appropriate message writer for the supplied content type
	 * @param output 
	 * @param contentType2
	 * @return
	 * @throws MessageException if no output writer is defined for the context type supplied
	 */
	private MessageWriter
	getOutputWriter ( ContentType contentType, CharEncoder charset)
	{
		switch ( contentType)
		{
			case TEXT:
				return new CharMessageWriter(charset);
			case BCD:
				return new BCDMessageWriter(charset);
			default:
				throw new MessageException ( "No MessageWriter defined for content=" + contentType);
		}
	}

	/**
	 * Create a message instance of the specified <code>type</code>, setting the field 
	 * values from properties of the <code>bean</code>, as named in the Message template
	 * 'name' field
	 * @param type (MTI) of ISO message to create
	 * @param bean holding value to populate message fields
	 * @param protocolParams map of protocol properties keyed by ISO field#
	 * @return
	 * @throws IllegalArgumentException if the type supplied is not defined in this factory's schema
	 */
	public Message
	createFromBean ( MTI type, Object bean)
	{
		if ( messages.containsKey ( type) == false)
		{
			throw new IllegalArgumentException ( "Message not defined for MTI=" + type);
		}
		Map<Integer, Object> params = new HashMap<Integer, Object>();;
		for ( FieldTemplate field :  messages.get ( type).getFields ().values ())
		{
			try
			{
				params.put ( field.getNumber (), PropertyUtils.getProperty ( bean, field.getName()));
			}
			catch  (Exception  e)
			{
				// ignore, as this value may be set later as a protocol parameter
/*				throw new IllegalArgumentException ( "Unable to access property [" 
							+ field.getName () + "] in supplied bean", e);*/
			}			
		}

		return createByNumbers ( type, params);
	}
	
	/**
	 * @param type
	 * @return
	 */
	public MessageTemplate
	getTemplate ( MTI type)
	{
		return messages.get ( type);
	}
	
	/**
	 * @param formatter
	 */
	public void
	addFormatter ( String type, TypeFormatter<?> formatter)
	{
		formatters.setFormatter ( type, formatter);
	}
	
	/**
	 * Answer with the formatter for message <code>type</code>
	 * @param type
	 * @return
	 */
	TypeFormatter<?>
	getFormatter ( String type)
	{
		TypeFormatter<?> result = null;
		try
		{
			result = formatters.getFormatter ( type);
		}
		catch ( Exception e)
		{
			// handled by result being left as null
		}
		if ( result == null)
		{
			throw new MessageException ( 
					"No formatter registered for field type=[" + type + "] in " + formatters);
		}
		return result;
	}
	
	/**
	 * @param bytes the message data to be parsed
	 * @return
	 * @throws ParseException 
	 * @throws IOException 
	 */
	public Message
	parse ( byte[] bytes)
		throws ParseException, IOException
	{
		return this.parse ( new ByteArrayInputStream ( bytes));
	}
	
	/**
	 * Parse a message from the supplied input stream
	 * 
	 * @param input stream from which an ISO8583 message can be read
	 * @return A message representation, set from the input data
	 * @throws ParseException if the input message is not well-formed or does not
	 * 				conform to the message specification configured
	 * @throws IllegalArgumentException if the input stream supplied is null
	 * @throws IOException when an error occurs reading from the input stream
	 */
	public Message
	parse ( InputStream input)
		throws ParseException, IOException
	{
		if ( input == null)
		{
			throw new IllegalArgumentException ( "Input stream cannot be null");
		}
		DataInputStream dis;
		if ( input instanceof DataInputStream == false)
		{
			dis = new DataInputStream ( input);
		}
		else
		{
			dis = (DataInputStream)input;
		}
		Message result = parser.parse ( dis);
		result.setTemplate ( messages.get ( result.getMTI ()));
		return result;
	}

	/**
	 * Create an ISO8583 message of the type requested, setting the field values
	 * from the supplied parameter map, matching the names in the 
	 * <code>&lt;iso:message&gt;</code> configuration for this message type
	 * 
	 * @param type MTI of the message to be created
	 * @param params map of message fields, keyed by names
	 * @return a new message instance for the requested type, 
	 * 			with fields set from the parameters supplied
	 * @throws IllegalArgumentException if the type is not defined in this factory's schema
	 */
	public Message
	createByNames ( MTI type, Map<String, Object> params)
	{
		if ( messages.containsKey ( type) == false)
		{
			throw new IllegalArgumentException ( "Message not defined for MTI=" + type);
		}
		// convert the name map supplied to a field number keyed map
		Map<Integer, Object> numParams = new HashMap<Integer, Object>();
		for ( FieldTemplate field : messages.get ( type).getFields ().values ())
		{
			numParams.put ( field.getNumber (), params.get ( field.getName ()));
		}
		return createByNumbers ( type, numParams);
	}

	/**
	 * Create an empty ISO8583 message of the type requested, from the configured
	 * <code>&lt;iso:message&gt;</code> template
	 * 
	 * @param mti type of message
	 * @return
	 */
	public Message
	create ( MTI mti)
	{
		Message result = new Message ( mti);
		MessageTemplate template = messages.get ( result.getMTI ());
		result.setHeader ( template.getHeader());
		result.setTemplate (template);
		return result;
	}
	
	/**
	 * Create a message as a duplicate of another, but using the message
	 * type specified (usually a response), setting its fields from the
	 * other's fields ("move-corresponding" semantics)
	 * <p/>
	 * Note: message unlikely to be valid until fields add/removed
	 * 
	 * @param mti type of target message
	 * @param source message to duplicate
	 * @return
	 * @throws IllegalArgumentException if the mti supplied is not defined in this factory's schema
	 */
	public Message
	duplicate ( MTI mti, Message source)
	{
		Message result = new Message ( mti);
		MessageTemplate template = messages.get ( result.getMTI ());
		if ( template == null)
		{
			throw new IllegalArgumentException ( "Message type [" + mti + "] not defined");
		}
		result.setTemplate ( template);
		result.setHeader ( source.getHeader());
		// move corresponding fields from source to target message, that is,
		// by corresponding field number
		for ( FieldTemplate field : template.getFields ().values ())
		{	
			if ( source.isFieldPresent ( field.getNumber ()) == false)
			{
				continue;
			}
			Object fieldValue = source.getFieldValue ( field.getNumber ());
			if ( fieldValue != null)
			{
				result.setFieldValue ( field.getNumber (), fieldValue);
			}
		}
		return result;
	}

	/**
	 * Answer with a byte array representation of the supplied message
	 * 
	 * @param message ISO8583 message to convert to a byte array
	 * @return byte array of message data, either text or binary depending upon the
	 * 			content type specified in the iso:schema in the configuration
	 * @throws MessageException if an error occurred creating the byte representation of the message
	 */
	public byte[]
	getMessageData ( Message message)
	{
		ByteArrayOutputStream baos = new ByteArrayOutputStream ();
		try
		{
			this.writeToStream ( message, new DataOutputStream ( baos));
			return baos.toByteArray ();
		}
		catch ( IOException e)
		{
			throw new MessageException ( "Failed to translate message to byte stream", e);
		}
	}
	
}
